Skip to content

fix(lint): a field-typed rule reads the registry's own type for an injected column, so created_at / updated_at stop escaping it (#16340) - #17430

Merged
os-bill merged 2 commits into
mainfrom
claude/issue-16340-lint-injected-temporal-columns
Sep 10, 2026
Merged

fix(lint): a field-typed rule reads the registry's own type for an injected column, so created_at / updated_at stop escaping it (#16340)#17430
os-bill merged 2 commits into
mainfrom
claude/issue-16340-lint-injected-temporal-columns

Conversation

@os-bill

@os-bill os-bill commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16340

Clause-②: no — nothing in this PR moves packages/spec/src. The card proposed extending resolveInjectedSystemColumns's plan to carry each injected column's type; that derivation already exists one module over as injectedSystemColumnDefs (packages/spec/src/data/injected-system-column-provenance.ts, the WHAT-half #8116 moved into the spec), so the fix is entirely a consumer change inside @objectstack/lint. The spec's authorable surface is byte-unchanged; the lint package is not a published authoring surface and a rule seeing MORE columns refuses nothing new at the spec door.

Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH

The author-visible symptom, measured first

⚠️ The dispatch framed this as a FALSE POSITIVE — "it tells them a field does not exist when the registry injects it". That is falsified. resolveFieldPath already returns { kind: 'ok', injected: true } for an injected leaf (that is what #5378 / injectedColumnsFor fixed), so no rule tells an author created_at does not exist. The author-visible symptom is SILENCE — a missed catch, exactly the failure direction the card's own "Measured" section states.

Every AUTHORING_RULES entry driven over one stack — one object declaring close_date: date / stage: select, authoring no created_at, and one dashboard bound to it through a dataset — on origin/main d57611dfd3:

authored filter before after
close_date: 'last_30_days' (authored date) refused refused
created_at: { $gte: 'last_30_days' } (ordering — arm 1, field-agnostic) refused refused
created_at: 'last_30_days' silent refused
created_at: { $eq: 'last_30_days' } silent refused
updated_at: { $in: ['last_30_days'] } silent refused
stage: 'this_quarter' (a select column) silent silent

Whole-run diff: 10 findings before, 13 after, and the diff of the two finding sets is exactly those three additions — nothing removed, nothing else added.

Before, verbatim: no finding of any rule, at any severity, on any of the three rows. There is no sentence to quote.

After, verbatim (dashboards[0].widgets[0].filter.created_at, severity error, rule filter-preset-comparand):

"last_30_days" is a dashboard date-range PRESET name, not a filter value. It is only understood by the dashboard date-filter positions (dateRange.defaultRange, a date global filter's defaultValue), where the console lowers it to {date-macro} bounds before querying. As a bare "$eq" comparand nothing resolves it: a declared datetime/date field refuses the query at the engine (INVALID_FILTER / 400), and any other column compares the literal string. Write the date-macro window instead — e.g. { $gte: '{30_days_ago}' } — or an ISO date such as "2026-01-15". Refused at authoring time so the error surfaces where the filter is written.

hint: Presets belong to the dashboard date-filter bar (dateRange.defaultRange, a date global filter's defaultValue). In a filter comparand, write the {date-macro} window the message names, or an ISO date.

That exact string is pinned in validate-preset-comparands.test.ts with toBe, not toContain — ⭐ a pin on the graph shape alone would have gone green while the author still received nothing.

Failure-fix order: route 1 — the construct is deleted

The construct that permitted the error was GraphObject.injected being a name-only set, which forced FieldPathVerdict to hand back an ok leaf with no meta and every consumer to carry a verdict.injected bail meaning "unanswerable". That set is gone: injected is now a a ReadonlyMap keyed by column name whose value is a GraphField carrying each column's registry definition, and the bails deleted themselves — no new check was added anywhere.

⚠️ Derived, not transcribed. Membership comes from resolveInjectedSystemColumns (via injectedColumnsFor) and the slice from injectedSystemColumnDefs (via the new injectedColumnDefsFor) — two derivations over ONE plan, both the spec tables applySystemFields spreads at registration. No "created_at is a datetime" is written anywhere in packages/lint. object-graph.test.ts asserts the graph's type equals the spec table's type by reading the table back, so a hand-copied literal could not pass that pin.

id is the single addressable column with no definition behind it — the DRIVER provisions the primary key — so its slice is empty. That is the truthful answer, and all three rules fall through their untyped branch there rather than inventing a verdict.

Two more rules inherit it, which is why the fix went to the seam

  • list-view-field-dottedcreated_at.x now reads as the datetime scalar it is and owner_id.name as the lookup it is. assertFilterIsMaterializable and the REST ingress have always answered 400 INVALID_FIELD for both; the linter was silent only because the type was missing here. (This one surfaced as a RED test in the package suite, not as a choice.)
  • dataset-include-unknown — its verdict.injected bail is deleted along with the comment that justified it ("its type is registry-owned and invisible here"), which this change makes false. include: ['owner_id'] joins; include: ['created_at'] derives no join and is refused.

A relationship hop through an injected column stays unknowable / injected-hop deliberately: the slice now carries reference, and traversing it would newly judge every path through a platform anchor wherever sys_user is compiled into the stack. That is a widening with its own findings to measure, and the docblocks say so rather than repeating the retired "invisible here" claim.

⚠️ Declared file face — larger than the claim comment's

The claim declared object-graph.ts + injected-system-columns.ts + tests. The real face is below; injected-system-columns.ts is untouched and three more lint files are in.

packages/lint/src/object-graph.ts                  (the construct)
packages/lint/src/system-fields.ts                 (the derivation)
packages/lint/src/validate-preset-comparands.ts    (the card's rule)
packages/lint/src/validate-list-view-field-refs.ts (inherited — forced by a red test)
packages/lint/src/validate-dataset-references.ts   (inherited — stale bail this change falsifies)
+ the four matching *.test.ts, + .changeset/

validate-list-view-field-refs.ts is also touched by open PR #17298 (a 3-line docblock edit at lines 190-197; mine is the adjacent skips paragraph at 203-210). git merge-tree --write-tree HEAD refs/pull/17298/head exits 0 with no conflicted path. The other four lane PRs (#17146, #17249, #17358, #17401) hold none of my paths — checked by diffing all five PR refs against origin/main, 86 busy paths, the same count the claim comment measured.

Verification

Ablation — two legs, both predicted RED before running, both restored and proved restored. Same-package relative imports, so no dist is in the resolution path; each leg proved the mutation reached disk by blob hash against HEAD:PATH before the run, and each restore leg is git checkout HEAD -- PATH proved by an empty git diff HEAD.

leg mutation result
A reinstate the verdict.injected bail in the oracle 3 failed / 46 passed — the message pins fell
B starve injectedColumnDefsFor to an empty map 5 failed / 44 passed — the derivation pin fell too

Tests (final commit d77c15a92d):

  • pnpm --filter @objectstack/lint test :: exit 0 — 103 files, 3729 tests passed (3724 before this PR).
  • pnpm --filter @objectstack/lint typecheck :: exit 0.
  • pnpm --filter '@objectstack/lint...' build :: exit 0.
  • npx eslint . --no-inline-config --format json :: exit 0 — 6552 files linted (population read from eslint's own config), 0 errors, 0 warnings. The whole tree, not a narrowing.
  • pnpm check:nul-bytes :: exit 0; plus a direct control-byte scan of all 10 changed paths with a lit fixture (probe fires, exit 0) and a dark fixture (probe silent, exit 1).

Gatesnode scripts/pm/dispatch-gates.mjs derives 59 families for this change set; all 59 run, and re-deriving after the final commit adds none. Every exit code was captured before any pipe.

  • 55 :: exit 0.
  • pnpm check:dual-build-cjs-loads :: exit 3, pnpm check:type-check-debt :: exit 3 — PREREQUISITE NOT MET (both read a whole-repo dist/). NOT MEASURED, neither pass nor fail; left to CI.
  • pnpm check:docs-transcript-drift :: exit 3 unbuilt, then exit 0 after pnpm --filter '@objectstack/lint...' build.
  • pnpm check:lean-entry-closure :: exit 3 — PREREQUISITE NOT MET (@objectstack/objectql/core dist absent). NOT MEASURED; left to CI.

⚠️ The derivation warns the tree is 8 commits behind origin/main and that 3 files it reads changed upstream (scripts/check-test-source-alias.mjs among them). The derived family list is byte-identical before and after git fetch origin main, so nothing here narrows on the stale copy — but CI is the authority on the merged tree.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH


Generated by Claude Code

…ield-typed rule can judge created_at / updated_at (#16340)

The object graph recorded registry-injected system columns by NAME only, so a
path resolving to one came back `{ kind: 'ok', injected: true }` with no `meta`
and every rule asking a second question about the leaf had to stay silent.
Measured before the change on one dashboard widget over one object declaring
`close_date: date`: `close_date: 'last_30_days'` was refused while
`created_at: 'last_30_days'`, `created_at: { $eq: … }` and
`updated_at: { $in: [ … ] }` all passed lint and the runtime publish gate — and
the engine then refused each of them with INVALID_FILTER / 400 on first render.

`GraphObject.injected` becomes a `ReadonlyMap<string, GraphField>` carrying each
column's definition, DERIVED from `injectedSystemColumnDefs` (the same spec
tables `applySystemFields` spreads at registration) rather than transcribed, so
lint cannot drift from the registry that provisions the column. The
`filter-preset-comparand` oracle drops its `verdict.injected` bail: the marker
says who wrote the column, the ruling turns on what it is. `id` keeps an empty
slice — the driver provisions the primary key and no definition describes it.

Pins the AUTHOR-FACING message verbatim, not only the graph shape, and pins the
derivation against the spec table so a hand-copied type cannot pass.

Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>
…ne in the dotted-filter and include rules (#16340)

Two more consumers of a resolved leaf's `meta` inherit the registry type the
object graph now carries, which is why the fix went to the seam rather than
into `filter-preset-comparand`.

`list-view-field-dotted` refuses `created_at.x` (a `datetime` scalar has
nothing beneath it) and `owner_id.name` (a `lookup` stores an id, not an
embedded document) — both already refused at the door by
`assertFilterIsMaterializable` with the registry's field map in hand, so the
linter's silence was the miss. `dataset-include-unknown` drops its
`verdict.injected` bail, whose stated reason ("its type is registry-owned and
invisible here") this change makes false: `include: ['owner_id']` joins,
`include: ['created_at']` derives no join and is refused.

`id` falls through the untyped branch of all three rules — the driver
provisions the primary key and no definition table describes it.

Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 10 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/validate-list-view-field-refs.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/validating-metadata.mdx (via last_30_days (literal, a string literal on a changed line))
  • content/docs/ui/dashboards.mdx (via last_30_days (literal, a string literal on a changed line), this_quarter (literal, a string literal on a changed line))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/validate-list-view-field-refs.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 92242041e738eec91b2ccd4e29c845682b53bb4apackageMentionDocs.

Which tree this was computed on

This run read content/docs from ffd733dd6cd67249427e0abe64dbfd94372d46ef — the merge of head d77c15a92d41ad1822623e2cbd9bfab71ad97cd3 into base 92242041e738eec91b2ccd4e29c845682b53bb4a, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ffd733dd6cd67249427e0abe64dbfd94372d46ef && git checkout ffd733dd6cd67249427e0abe64dbfd94372d46ef
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 92242041e738eec91b2ccd4e29c845682b53bb4a d77c15a92d41ad1822623e2cbd9bfab71ad97cd3 && git checkout -B drift-repro 92242041e738eec91b2ccd4e29c845682b53bb4a && git merge --no-ff d77c15a92d41ad1822623e2cbd9bfab71ad97cd3

node scripts/docs-audit/affected-docs.mjs --json 92242041e738eec91b2ccd4e29c845682b53bb4a

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 92242041e738eec91b2ccd4e29c845682b53bb4a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants